昨天讓模型第一次自己選 Tool。
流程只走到:
問題
↓
模型選擇 Tool
↓
Action
↓
Observation
拿到 Observation 後就停了。
今天只多一件事:
把 Tool 的結果放回對話,再問模型下一步。
然後重複這件事,直到模型回答,或 Host 決定該停了。
看起來就是一個迴圈。
實際上也真的只是一個迴圈。
真正麻煩的不是 for 怎麼寫,而是:
什麼情況才算真的完成?
Agent 一開始只有兩則訊息:
system
user
模型回傳後,先把 Assistant 訊息保留下來。
如果它要求 Tool,就真的執行,接著再把 Observation 放進訊息列表:
messages.append(msg)
for call in msg.tool_calls:
obs = await tools.call(
call.name,
call.arguments,
)
observations.append(obs)
messages.append(
ChatMessage(
role="tool",
tool_name=call.name,
content=(
("ERROR: " if obs.is_error else "")
+ obs.output
),
)
)
下一輪模型拿到的就不再只有原始問題,而是:
使用者問了什麼
↓
上一輪模型要求了什麼 Tool
↓
Tool 實際回了什麼
然後再決定下一步。
這就形成最小的 Agent Loop:
Question
↓
Model
↓
Action
↓
Observation
↓
Model
↓
Action / Answer
有一個細節不要省。
模型提出 Tool Call 的那則 Assistant 訊息也要留下來。
不要只把 Tool Result 硬塞回去,不然對話裡會少掉:
這份 Observation 到底是在回應哪一次工具要求?
在這份實作裡,tool_name 也會跟著 Tool 訊息保存,讓後面的流程知道結果來自哪個工具。
如果只是:
while True:
...
那不是 Agent。
那是事故的前置作業。
今天先定義幾種明確的停止原因。
answered模型不再要求 Tool,而且回傳了正常文字答案:
stop_reason = answered
這才代表流程正常完成。
max_stepsAgent 已經跑到最大迴圈數:
stop_reason = max_steps
這代表:
還沒有正常完成,但 Host 不允許它繼續跑。
tool_budget模型下一輪要求的 Tool 數量會超過剩餘額度:
stop_reason = tool_budget
所以 Host 直接停止,不執行這批呼叫。
empty_answer模型沒有 Tool Call,也沒有有效答案:
stop_reason = empty_answer
這也不能算正常完成。
最後結果就不只是:
"完成了"
而是會保留:
answer
stop_reason
observations
steps
tool_calls
這些資訊之後交給 Supervisor 或其他 Agent 時會很重要。
因為:
正常完成
跟:
跑到限制被迫停止
完全是兩回事。
假設 Tool 預算只剩:
2 次
結果模型一次提出:
read_file(...)
read_file(...)
list_files(...)
共三個 Tool Call。
這時不能執行前兩個,再把第三個丟掉。
今天的做法是:
先檢查整批 Tool Calls
↓
超出剩餘 Budget
↓
整批不執行
因為執行一半,狀態會變得很難解釋。
今天的 Tool 都是唯讀,所以看起來還好。
但假設以後變成:
建立檔案
修改設定
發送訊息
刪除資料
執行一半再停,事情就麻煩了。
所以 Tool Budget 不是寫在 Prompt 裡提醒模型:
請最多使用八次工具
而是由 Host 自己記:
tool_calls_used += len(msg.tool_calls)
限制必須由程式執行。
不能把計數器交給模型。
今天的入口反而變得很簡單:
import asyncio
from ironman.agent import print_result, run_agent
from ironman.llm import OllamaClient
from ironman.mcp_bridge import devbench
async def main() -> None:
async with devbench() as tools, OllamaClient() as llm:
result = await run_agent(
llm,
tools,
)
print_result(result)
assert result.stop_reason == "answered"
assert result.observations
if __name__ == "__main__":
asyncio.run(main())
main.py 不再自己處理:
模型呼叫
工具執行
Observation
下一輪
停止條件
全部收進:
run_agent()
裡面。
概念上就是:
for step in range(max_steps):
response = await llm.chat(
messages,
tools=tools.specs,
)
messages.append(response.message)
if response.message.tool_calls:
# 檢查 Budget
# 執行 Tool
# 寫回 Observation
continue
# 沒有 Tool Call
# 有答案 → answered
# 沒答案 → empty_answer
到這裡,已經有一個真正能反覆使用 MCP Tool 的 Agent。
answered 不代表答案一定正確這點很容易混在一起。
假設最後看到:
stop_reason = answered
它只能證明:
Agent 正常走完流程
模型最後給出答案
不能證明:
答案一定正確
例如 read_file 回傳:
42 | MODEL_NAME = "qwen3"
Tool 回來的第 42 行,是我們真的觀察到的資料。
如果最後模型卻回答:
MODEL_NAME 在第 47 行
那流程還是可以正常 answered,但內容是錯的。
所以我會把兩件事分開:
執行是否成功
≠
答案是否正確
今天先處理前者。
後面的評估再處理後者。
這次會確認幾件事:
模型真的呼叫過 Tool
取得過 Observation
最後正常 answered
回答包含來源檔案
還會測一個很重要的邊界:
tool_budget = 0
這種情況下,即使模型想呼叫 Tool,Host 也不能偷偷執行。
所以測試不是只看:
有沒有印出一段文字
而是要看 Agent 實際走過什麼流程。
這些模型相關測試會真的連本機 Ollama,不用固定回覆的 Fake Model 假裝它已經具備工具選擇能力。
今天最值得留下來的一件事,不是 ReAct 的 for 迴圈。
而是:
所有執行限制都必須由 Host 落實。
例如:
最多幾個 Step
最多幾次 Tool Call
哪些 Tool 能用
哪些檔案能讀
一次 Observation 最多多大
這些都不應該只寫成:
請注意不要……
請最多……
請不要讀……
Prompt 可以告訴模型規則。
但真正的限制,還是要有程式碼。
Prompt 是要求
Host 才是執行者
這個觀念後面做安全治理時還會再回來。
目前每跑一輪,就會把新的內容繼續加進:
messages
裡面。
user
assistant
tool
assistant
tool
assistant
tool
...
短任務沒什麼問題。
但如果 Agent 持續跑很久,Context 會越來越大。
即使每一次 Tool Result 都有做截斷,也不代表整段對話可以無限保留。
所以之後還會碰到另一個問題:
哪些歷史真的需要留下?
這也是 Session、Memory 與 Context Management 要解決的事情。
今天先不處理。
先把 Agent 的基本生命週期走通。
Day 12 做的是:
模型選 Tool
↓
Action
↓
Observation
今天則把 Observation 放回去:
Question
↓
Model
↓
Action
↓
Observation
↓
Model
↓
...
↓
Answer
到這裡,我們終於有了第一個完整的 ReAct Agent。
而且沒有 Agent Framework。
就是:
Message State
+
LLM
+
MCP Tools
+
Loop
+
Stop Conditions
明天開始加入 LangGraph。
但不是因為今天這個 Agent「不能用」。
而是當流程開始出現更多:
狀態
分支
節點
重試
錯誤處理
一個手寫 for 迴圈會越來越難維護。
Day 14:
ReAct 流程越寫越亂?用一天認識 LangGraph。